package org.melanesia.sql;

import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

import org.melanesia.beans.AccessorFactory;
import org.melanesia.beans.BeanUtils;
import org.melanesia.beans.PropertyAccessor;
import org.melanesia.conf.Config;
import org.melanesia.converters.JavaToJavaConverter;
import org.melanesia.converters.JavaToJdbcConverter;
import org.melanesia.converters.TypeConverter;
import org.melanesia.converters.exceptions.ConversionException;
import org.melanesia.converters.exceptions.UnsupportedClassException;
import org.melanesia.ref.ReferenceMonitor;
import org.melanesia.sql.exceptions.DataBindingException;
import org.melanesia.sql.utils.NamedParametersHelper;

/**
 * Base class for database access classes.
 *
 * @author marcin.kielar
 */
public abstract class AbstractSQL {

    /** Logger. */
    private static Logger logger = Logger.getLogger(AbstractSQL.class.getName());

    /** JDBC connection. */
    private final Connection connection;

    /** Type converter used for type conversion. */
    private final TypeConverter typeConverter;

    /** Named parameters helper. */
    private final NamedParametersHelper named;

    /**
     * Constructs the AbstractSQL object.
     *
     * @param connection
     *            jdbc connection
     * @param typeConverter
     *            type converter
     * @param sql
     *            query/call string
     */
    AbstractSQL(final Connection connection, final TypeConverter typeConverter, final String sql) {

        if (Config.isDebugMode()) {

            // Cleanup forgotten references
            // Do this only in debug mode due to big influence on efficiency,
            // especially on JBoss.

            ReferenceMonitor.cleanup();
        }

        this.connection = connection;
        this.typeConverter = typeConverter;

        this.named = new NamedParametersHelper(sql);
    }

    /**
     * Returns jdbc connection.
     *
     * @return jdbc connection
     */
    protected final Connection getConnection() {
        return connection;
    }

    /**
     * Returns type monverter.
     *
     * @return type monverter
     */
    protected final TypeConverter getTypeConverter() {
        return typeConverter;
    }

    /**
     * Returns sql statement for execution.
     *
     * @return sql statement
     */
    protected final String getSql() {
        return named.getSql();
    }

    /**
     * Returns original sql statement as it was passed in {@link AbstractSQL#AbstractSQL(Connection, TypeConverter, String)}.
     *
     * @return sql statement
     */
    protected final String getOriginalSql() {
        return named.getOriginalSql();
    }

    /**
     * Return a helper object for named parameters handling.
     *
     * @return named parameters handling helper object
     */
    protected final NamedParametersHelper getNamedParametersHelper() {
        return named;
    }

    /**
     * Binds input parameter to the statement.
     *
     * @param stmt
     *            jdbc statement
     * @param parameter
     *            parameter to set
     * @throws DataBindingException
     *             thrown when binding fails, usually contains cause that
     *             explains actual reason
     *
     * @see NamedParametersHelper
     */
    protected final void bindInputParameter(final PreparedStatement stmt, final InputParameter parameter) throws DataBindingException {
        bindInputParameter(stmt, parameter, this.named);
    }

    /**
     * Binds input parameter to the statement.
     *
     * @param stmt
     *            jdbc statement
     * @param parameter
     *            parameter to set
     * @param named
     *            explicitly specified named parameters helper for situations where query is transformed for execution.
     *
     * @throws DataBindingException
     *             thrown when binding fails, usually contains cause that
     *             explains actual reason
     *
     * @see NamedParametersHelper
     */
    protected final void
            bindInputParameter(final PreparedStatement stmt, final InputParameter parameter, final NamedParametersHelper named)
                    throws DataBindingException {
    try {
            String parameterName = parameter.getName();
            Object parameterValue = parameter.getValue();

            int jdbcType = Types.NULL;

            JavaToJdbcConverter converter = parameter.getConverter();

            // If parameter has an explicit converter, then we get the JDBC type from that converter (instead of passing Types.NULL).
            if (converter != null) {
            	jdbcType = converter.getJdbcType(typeConverter);
            }

            // If parameter has no explicit converter, and is not null, then we do a converter lookup using standard method.
            if (converter == null && parameterValue != null) {

                Class<?> parameterClass = parameterValue.getClass();

                if (converter == null) {
                    converter = typeConverter.findJavaToJdbcConverter(parameterClass);
                }

                if (converter == null) {
                    // There is no converter, so no way to determine JDBC Type for passed value
                    throw new UnsupportedClassException(parameterClass);
                }

                jdbcType = converter.getJdbcType(typeConverter);
            }

            if (parameterValue != null) {
            	parameterValue = converter.convert(typeConverter, parameterValue, stmt, parameterName);
            }

            if (Config.isDebugMode()) {
                logger.info("Setting parameter: " + parameterName + " to value: " + parameterValue + " of type "
                        + (parameterValue == null ? "unknown" : parameterValue.getClass()) + " and jdbc type: " + jdbcType
                        + " on indices: " + named.getIndices(parameterName));
            }

            // Finally, set the parameter value to all of its occurrences
            List<Integer> indexes = named.getIndices(parameterName);
            for (int i : indexes) {
                stmt.setObject(i, parameterValue, jdbcType);
            }
        } catch (Throwable t) {
            throw new DataBindingException(parameter.getName(), t);
        }
    }

    /**
     * Binds output parameter to the statement.
     *
     * @param stmt
     *            jdbc statement
     * @param parameter
     *            parameter to set
     * @throws DataBindingException
     *             thrown when binding fails, usually contains cause that
     *             explains actual reason
     */
    protected final void bindOutputParameter(final CallableStatement stmt, final OutputParameter parameter) throws DataBindingException {
        try {
            String parameterName = parameter.getName();
            Class<?> parameterClass = parameter.getParameterClass();

            Integer jdbcType = parameter.getJdbcType();
            String databaseTypeName = parameter.getDatabaseTypeName();

            if (jdbcType == null) {
	            JavaToJdbcConverter converter = getTypeConverter().findJavaToJdbcConverter(parameterClass);

	            if (converter != null) {
	                jdbcType = converter.getJdbcType(typeConverter);

	                if (databaseTypeName == null) {
	                	databaseTypeName = converter.getDatabaseTypeName(typeConverter);
	                }
	            }
            }

            if (jdbcType == null) {
            	// There is no converter, so no way to determine JDBC Type
                throw new UnsupportedClassException(parameterClass);
            }

            List<Integer> indexes = named.getIndices(parameterName);
            for (int i : indexes) {
            	if (databaseTypeName != null) {
            		stmt.registerOutParameter(i, jdbcType, databaseTypeName);
            	} else {
            		stmt.registerOutParameter(i, jdbcType);
            	}
            }
        } catch (Throwable t) {
            throw new DataBindingException(parameter.getName(), t);
        }
    }

    /**
     * Returns raw value (without any conversion) from a statement.
     *
     * @param stmt
     *            callable statement
     * @param parameterName
     *            name of the output parameter to return
     * @return value of the specified parameter
     * @throws DataBindingException
     *             if there is more than one occurrence of this output parameter
     *             in the SQL
     */
    protected final Object getOutputParameterRawValue(final CallableStatement stmt, final String parameterName)
            throws DataBindingException {
        try {

            List<Integer> indices = named.getIndices(parameterName);
            if (indices == null || indices.size() != 1) {
                throw new ArrayIndexOutOfBoundsException("Invalid number of indices for output parameter \"" + parameterName + "\": "
                        + indices.size() + ". Should be: 1.");
            }

            return stmt.getObject(indices.get(0));

        } catch (Throwable t) {
            throw new DataBindingException(parameterName, t);
        }
    }

    /**
     * Sets the value of given <code>propertyName</code> of a <code>bean</code>
     * to specified <code>value</code> or its converted form. Conversion is made
     * using type converter when <code>value</code> class is different than
     * property class. The conversion can be also made, when
     * <code>beanClassMapping</code> forces usage of different class for this
     * property, and such class differs from <code>value</code> class.
     *
     * The <code>beanClassMapping</code> collection should be used to pass class
     * definitions for properties in case when: - property is a composite
     * property as described in
     * {@link org.melanesia.sql.Query#setIntermediatePropertyClass(String, Class)}
     * and its type is an interface type and thus cannot be instantiated -
     * property type is not appropriate, and should be instantiated using some
     * sub-type.
     *
     * @param bean
     *            bean to set property value
     * @param propertyName
     *            property to set the value to
     * @param value
     *            value to set
     * @param intermediatePropertyClasses
     *            collection of class definitions associated to property names
     * @param explicitConverter
     *            converter to use for value conversion
     * @throws DataBindingException
     *             thrown when binding fails, usually contains cause that
     *             explains actual reason
     */
    protected final void bindResultToBean(final Object bean, final String propertyName, final Object value,
            final Map<String, Class<?>> intermediatePropertyClasses, final JavaToJavaConverter explicitConverter)
            throws DataBindingException {
        try {
            String javaPropertyName = BeanUtils.sqlToCamelCase(propertyName);
            PropertyAccessor accessor = AccessorFactory.createAccessor(bean.getClass(), javaPropertyName);

            Object convertedValue = convertValue(accessor.getPropertyClass(), value, explicitConverter);
            accessor.set(bean, convertedValue, intermediatePropertyClasses);

        } catch (Throwable t) {
            throw new DataBindingException(propertyName, t);
        }
    }

    /**
     * Returns the given {@code value} converted to the type defined by
     * {@code returnedClass}.
     *
     * @param <T>
     *            returned class
     * @param returnedClass
     *            returned class
     * @param propertyName
     *            name of the column / output parameter that is being bound
     * @param value
     *            column / output parameter value
     * @param explicitConverter
     *            converter to use for value conversion
     * @return converted value
     * @throws DataBindingException
     *             thrown when binding fails, usually contains cause that
     *             explains actual reason
     */
    protected final <T> T bindResultToSimpleValue(final Class<T> returnedClass, final String propertyName, final Object value,
            final JavaToJavaConverter explicitConverter)
            throws DataBindingException {

        try {
            // Convert
            @SuppressWarnings("unchecked")
            T convertedValue = (T) convertValue(returnedClass, value, explicitConverter);

            // Try cast to verify if autounboxing will work.
            @SuppressWarnings("unchecked")
            T acceptedValue = (T) BeanUtils.cast(convertedValue, returnedClass);

            return acceptedValue;

        } catch (Throwable t) {
            throw new DataBindingException(propertyName, t);
        }
    }

    /**
     * Converts given value from its current type into
     * <code>returnedClass</code> using {@link #typeConverter}.
     *
     * @param returnClass
     *            class to convert the value to
     * @param value
     *            value to convert
     * @param explicitConverter
     *            explicit converter to be used instead of looking up a
     *            converter in the @{link
     *            org.melanesia.converters.TypeConverter} repository.
     * @throws ConversionException
     *             thrown when conversion fails
     * @return converted value
     */
    private Object convertValue(final Class<?> returnClass, final Object value, final JavaToJavaConverter explicitConverter)
            throws ConversionException {
        Object convertedValue = value;

        if (convertedValue != null) {

            JavaToJavaConverter converter = explicitConverter;

            if (converter == null) {
                converter = typeConverter.findJavaToJavaConverter(value.getClass(), returnClass);
            }

            if (converter == null) {

                // There is no converter - if the value and return classes are
                // incompatible, then we have a problem.
                if (!BeanUtils.isAssignableFrom(returnClass, convertedValue.getClass())) {
                    throw new UnsupportedClassException(value.getClass());
                }
            } else {
                convertedValue = converter.convert(typeConverter, value);
            }
        }

        return convertedValue;
    }

}
